package org.windowkit.toolkit.widget.date;

import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.FocusEvent;
import java.awt.event.FocusListener;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.List;

import javax.swing.ActionMap;
import javax.swing.JComponent;
import javax.swing.JFormattedTextField;
import javax.swing.JPanel;
import javax.swing.JSpinner;
import javax.swing.SpinnerListModel;
import javax.swing.SpinnerModel;
import javax.swing.SpinnerNumberModel;
import javax.swing.SwingConstants;
import javax.swing.JFormattedTextField.AbstractFormatter;
import javax.swing.event.CaretEvent;
import javax.swing.event.CaretListener;
import javax.swing.event.ChangeEvent;
import javax.swing.plaf.basic.BasicArrowButton;
import javax.swing.text.DateFormatter;
import javax.swing.text.DefaultFormatterFactory;
import javax.swing.text.MaskFormatter;

import org.apache.commons.lang.StringUtils;
import org.windowkit.toolkit.event.EventDispatcher;


/**
 * This class allows the user to cycle through a list or range of values
 * including numbers, list items, and dates.
 * @author Eric Lundin
 */
@SuppressWarnings("serial")
public class Spinner extends JComponent {

    /** The model for this Spinner. */
    private SpinnerModel model;

    /** Used to determine if value changed by user. */
    private Object initialValue;

    /**
     * When enabled in a date spinner this button will show a calendar from
     * which the user will be able to select a date.
     */
    private BasicArrowButton calendarButton;

    /** This object is the calendar which will be displayed. */
    private PopupMenuCalendar calendarPopupMenu;

    /** Stores whether we wish to display the calendar dialog buttton. */
    private boolean showCalendarPopup;

    /** The spinner itself */
    private JSpinner spinner;

    /** Panel to hold components */
    private JPanel componentPanel;

    private EventDispatcher eventDispatcher;


    /**
     * Create a date spinner
     *
     * @param initialValue
     *            THe initial date to display.
     * @param lowerBound
     *            The lowest(earliest) date to allow, pass null for no lower
     *            bound.
     * @param upperBound
     *            The highest(latest) date to allow, pass null for no upper
     *            bound.
     * @param increment
     *            The amount to increment the date by. Use one of, case
     *            Calendar.YEAR, Calendar.MONTH, Calendar.DAY_OF_MONTH,
     */
    public Spinner(final Date initialValue, final Date lowerBound, final Date upperBound,
            final int increment, final boolean calendarPopup) {

        spinner = new JSpinner();
        try {
            model = new SpinnerDateModel(initialValue, lowerBound,
                    upperBound, increment);
        } catch (ParseException e) {
            throw new IllegalArgumentException("Invalid date passed in: "
                    + e.getMessage());
        }
        initDateComponents(model, calendarPopup);
        initLayout();
    }

    /**
     * Create a date spinner
     *
     * @param initialValue
     *            The initial date to display.
     * @param lowerBound
     *            The lowest(earliest) date to allow, pass null for no lower
     *            bound.
     * @param upperBound
     *            The highest(latest) date to allow, pass null for no upper
     *            bound.
     * @param increment
     *            The amount to increment the date by. Use one of, case
     *            Calendar.YEAR, Calendar.MONTH, Calendar.DAY_OF_MONTH,
     */
    public Spinner(final String initialValue, final String lowerBound, final String upperBound,
            final int increment, final boolean calendarPopup) {

        spinner = new JSpinner();
        try {
            model = new SpinnerDateModel(initialValue, lowerBound,
                    upperBound, increment);
        } catch (ParseException e) {
            throw new IllegalArgumentException("Invalid date passed in: "
                    + e.getMessage());
        }
        initDateComponents(model, calendarPopup);
        initLayout();
    }

    private void initDateComponents(final SpinnerModel mod, final boolean calendarPopup) {
        spinner.setModel(mod);

        SpinnerEditor tf = new SpinnerEditor(new SpinnerDateFormatter(),
                spinner);
        spinner.setEditor(tf);
        tf.getTextField().addCaretListener(new SpinnerDateCaretListener());
        try {
            tf.commitEdit();
        } catch (ParseException e) {
            e.printStackTrace();
        }
        spinner.setValue(initialValue);
        this.showCalendarPopup = calendarPopup;
        this.initialValue = spinner.getValue();
        eventDispatcher = new EventDispatcher(this);
        tf.getTextField().addFocusListener(eventDispatcher);

    }

    /**
     * Create a number spinner
     *
     * @param initialValue
     *            THe initial number to display.
     * @param lowerBound
     *            The lowestnumber to allow, pass null for no lower bound.
     * @param upperBound
     *            The highest number to allow, pass null for no upper bound.
     * @param increment
     *            The amount to increment the number by.
     */
    public Spinner(final Number initialValue, final Comparable<Number> lowerBound,
            final Comparable<Number> upperBound, final Number stepSize) {
        super();
        model = new SpinnerNumberModel(initialValue, lowerBound, upperBound,
                stepSize);
        spinner.setModel(model);
        this.initialValue = spinner.getValue();
        initLayout();
    }

    /**
     * Create a list spinner.
     *
     * @param componentID
     *            The unique component ID.
     * @param listItems
     *            The items to cycle through.
     */
    public Spinner(final List listItems) {
        super();
        model = new SpinnerListModel(listItems);
        spinner.setModel(model);
        this.initialValue = spinner.getValue();
        initLayout();
    }

    /**
     * Determines whether the value of the spin field has changed.
     *
     * @return True if data has changed. False otherwise.
     */
    public boolean isDataChanged() {
        return !this.initialValue.equals(spinner.getValue());

    }

    /**
     * Set the value of the underlying Spinner.
     * @param value the value
     */
    public void setValue(Object value) {
    	spinner.setValue(value);
    }

    /**
     * If we need to add a button for the calendar dialog we will do it here
     * upon notification of our getting a parent.
     */
    @SuppressWarnings("serial")
    public void initLayout() {
        GridBagConstraints gbc = new GridBagConstraints();
        componentPanel = new JPanel(new GridBagLayout());

        gbc.gridx = 0;
        gbc.gridy = 0;
        gbc.weightx = 1.0;
        componentPanel.add(spinner, gbc);

        if (showCalendarPopup) {
            calendarButton = new BasicArrowButton(SwingConstants.EAST) {

                @Override
                public Dimension getPreferredSize() {
                    Dimension size = super.getPreferredSize();
                    size.height = spinner.getPreferredSize().height - 1;
                    return size;
                }
            };
            calendarButton.addActionListener(new SpinnerActionListener(this));
            gbc = new GridBagConstraints();
            gbc.gridx = 1;
            gbc.gridy = 0;
            gbc.weightx = 0.0;
            componentPanel.add(calendarButton, gbc);
        }

        setLayout(new BorderLayout());
        add(componentPanel, BorderLayout.LINE_START);

    }

    @SuppressWarnings("serial")
    protected class SpinnerEditor extends JSpinner.DefaultEditor {

        @Override
        public void commitEdit() throws ParseException {
            super.commitEdit();
        }

        /**
         * Creates a spinner editor.
         * @param formatter The formatter to use.
         * @param spinner The spinner to use this editor on.
         */
        public SpinnerEditor(final AbstractFormatter formatter, final JSpinner spinner) {
            super(spinner);
            // We create a text field which extends the normal
            // FormattedTextField in order to highlight entire text when focus
            // is gained.
            SpinnerTextField ftf = new SpinnerTextField();
            ftf.setName("Spinner.formattedTextField");
            ftf.setValue(spinner.getValue());
            ftf.addPropertyChangeListener(this);
            ftf.setEditable(false);

            String toolTipText = spinner.getToolTipText();
            if (toolTipText != null) {
                ftf.setToolTipText(toolTipText);
            }
            // Remove super's TextField, add ours.
            remove(getComponent(0));
            add(ftf);

            setLayout(this);
            spinner.addChangeListener(this);

            ActionMap ftfMap = ftf.getActionMap();

            if (ftfMap != null) {
                ftfMap.put("increment", null);
                ftfMap.put("decrement", null);
            }

            getTextField().setFormatterFactory(
                    new DefaultFormatterFactory(formatter));
            getTextField().setEditable(true);

        }

        /**
         * {@inheritDoc}
         */
        @Override
        public JFormattedTextField getTextField() {
            return (JFormattedTextField) getComponent(0);
        }

        /**
         * {@inheritDoc}
         */
        @Override
        public void stateChanged(final ChangeEvent e) {
            int caretpos = getTextField().getCaretPosition();
            super.stateChanged(e);
            getTextField().setCaretPosition(caretpos);
        }

    }

    @SuppressWarnings("serial")
    public class DefaultSpinnerTextField extends JFormattedTextField {
        @Override
        protected void processFocusEvent(final FocusEvent e) {
            super.processFocusEvent(e);
            if (e.getID() == FocusEvent.FOCUS_GAINED) {
				selectAll();
			}
        }

        /**
         * Replace the currently selected text with the given text <B>IF:</B>
         * <ul>
         * <li>There is text to paste</li>
         * <li>The selection is the same size as the given text.</li>
         * <li>Pasting this string in will not create an invalid date</li>
         * </ul>
         */
        @SuppressWarnings("unused")
        public void replaceSelection(final String text, final boolean undoable) {
            // Stubbed to stop warnings from MJTextUndo not being implemented.
            // Cut/paste handled by default clipboard manager.
        }

        /**
         * Allows undo of paste operations
         */
        public void undo() {
            // Stubbed to stop warnings from MJTextUndo not being implemented.
            // Cut/paste handled by default clipboard manager.
        }


        public boolean hasundo() {
            // Stubbed to stop warnings from MJTextUndo not being implemented.
            // Cut/paste handled by default clipboard manager.
            return true;
        }

        public boolean editAllowed() {
            // Stubbed to stop warnings from MJTextUndo not being implemented.
            // Cut/paste handled by default clipboard manager.
            return true;
        }
    }

    /**
     * Extends processFocusEvent(). Otherwise the focus listener meethods get
     * the event and then this method changes selection. This way the super
     * processing still gets done but we can do our highlighting.
     *
     * @author Eric Lundin
     */
    @SuppressWarnings("serial")
    public class SpinnerTextField extends DefaultSpinnerTextField {

        public SpinnerTextField() {
            super();
            setColumns(12);
        }

        /**
         * {@inheritDoc}
         */
        @Override
        public int getHorizontalAlignment() {
            return SwingConstants.CENTER;
        }

        /**
         * Overridden to keep columns at 8.
         */
        @Override
        @SuppressWarnings("unused")
        public void setColumns(final int columns) {
            super.setColumns(8);
        }

        @Override
        public Dimension getPreferredSize() {
            // TODO: If/when we upgrade to 1.6 remove this method. This is a
            // workaround for Sun Bug # 6389753
            Dimension size = super.getPreferredSize();
            size.height += 2;

            return size;
        }

//        @Override
//        @SuppressWarnings("unused")
//        public void setFont(Font f) {
//            super.setFont(MJFont.NORMAL_FONT);
//        }

        // @Override
        // protected void processFocusEvent(FocusEvent e) {
        // super.processFocusEvent(e);
        // if (e.getID() == FocusEvent.FOCUS_GAINED)
        // selectAll();
        // }

    }

//    protected class EventDispatcher implements FocusListener {
//
//        JComponent source;
//
//        ArrayList<FocusListener> listenerList;
//
//        public EventDispatcher(JComponent source) {
//            this.source = source;
//            listenerList = new ArrayList<FocusListener>();
//        }
//
//        public void addFocusListener(FocusListener listener) {
//            if (listener != null)
//                listenerList.add(listener);
//        }
//
//        public void removeFocusListener(FocusListener listener) {
//            listenerList.remove(listener);
//        }
//
//        /*
//         * (non-Javadoc)
//         *
//         * @see java.awt.event.FocusListener#focusGained(java.awt.event.FocusEvent)
//         */
//        public void focusGained(FocusEvent e) {
//            FocusEvent newEvent = new FocusEvent(source, e.getID(), e
//                    .isTemporary(), e.getOppositeComponent());
//            for (FocusListener current : listenerList)
//                if (current != null)
//                    current.focusGained(newEvent);
//        }
//
//        /*
//         * (non-Javadoc)
//         *
//         * @see java.awt.event.FocusListener#focusLost(java.awt.event.FocusEvent)
//         */
//        public void focusLost(FocusEvent e) {
//            FocusEvent newEvent = new FocusEvent(source, e.getID(), e
//                    .isTemporary(), e.getOppositeComponent());
//            for (FocusListener current : listenerList)
//                if (current != null)
//                    current.focusLost(newEvent);
//        }
//
//    }

    protected class SpinnerDateCaretListener implements CaretListener {

        /**
//         * Constructor.
         */
        public void caretUpdate(final CaretEvent e) {
            switch (e.getDot()) {
            case 0:
            case 1:
            case 2:
                ((SpinnerDateModel) model).setCalendarField(Calendar.MONTH);
                break;

            case 3:
            case 4:
            case 5:
                ((SpinnerDateModel) model)
                        .setCalendarField(Calendar.DAY_OF_MONTH);
                break;
            case 6:
            case 7:
            case 8:
            case 9:
            case 10:
                ((SpinnerDateModel) model).setCalendarField(Calendar.YEAR);
            }

        }
    }

    @SuppressWarnings("serial")
    private class SpinnerDateFormatter extends MaskFormatter {
        private final String MASK = "**/**/****";

        private static final String STRIP_NON_NUMERIC_EXPRESSION = "[^\\p{Digit}]";

        private final String PLACEHOLDER = "MM/DD/YYYY";

        SimpleDateFormat format = new SimpleDateFormat("MM/dd/yyyy");

        DateFormatter formatter = new DateFormatter(format);

        public SpinnerDateFormatter() {
            super();
            try {
                setMask(MASK);
            } catch (ParseException e) {
                e.printStackTrace();
            }
            setPlaceholder(PLACEHOLDER);
            setPlaceholderCharacter('_');
            setCommitsOnValidEdit(true);
            setAllowsInvalid(false);
            setValueContainsLiteralCharacters(true);
        }

        @Override
        public String valueToString(final Object value) throws ParseException {
            String returnValue = getPlaceholder();
            String mask = MASK;
            if (value == null) {
				return getPlaceholder();
			}
            String inputValue = ((String) value).replaceAll(
                    STRIP_NON_NUMERIC_EXPRESSION, "");
            if (inputValue.length() < getMinimumDigits()) {
				return getPlaceholder();
			}
            for (int i = 0; i < inputValue.length(); i++) {
                returnValue = super.valueToString(returnValue.replaceFirst(
                        String.valueOf(getPlaceholder().replace("/", "")
                                .charAt(i)), String.valueOf(inputValue
                                .charAt(i))));
            }
            if (mask != null) {
                setMask(getMaskForString(mask, returnValue));
                returnValue = returnValue.substring(0, getMask().length());
                setMask(mask);
            } else {
                returnValue = super.valueToString(returnValue);
            }
            return returnValue;
        }

        private int getMinimumDigits() {
            return 8;
        }

        @Override
        public Object stringToValue(final String value) throws ParseException {
            Object returnValue;
            String mask = MASK;
            StringBuilder formatBuilder;
            if (value.length() < getMinimumDigits()) {
				return null;
			}
            if (mask != null) {
                setMask(getMaskForString(mask, value));
                returnValue = super.stringToValue(value.substring(0, getMask()
                        .length()));
                setMask(mask);
            } else {
                returnValue = super.stringToValue(value);
            }

            formatBuilder = new StringBuilder((String) returnValue);
            for (int i = 0; i < ((String) returnValue).length(); i++) {
                if (!StringUtils.isNumeric(formatBuilder
                        .substring(i, i + 1))
                        && formatBuilder.charAt(i) != '/') {
                    if (i >= 0 && i <= 1) {
						formatBuilder.replace(i, i + 1, "M");
					}
                    if (i >= 3 && i <= 4) {
						formatBuilder.replace(i, i + 1, "D");
					}
                    if (i >= 6) {
						formatBuilder.replace(i, i + 1, "Y");
					}
                }
            }
            returnValue = formatBuilder.toString();
            return returnValue;
        }

        /**
         * Gets the mask to use for the given string. This allows us to have
         * variable length phone numbers i.e. 10 digits plus an optional
         * variable length extension.
         *
         * @param mask
         *            The starting mask;
         * @param value
         *            The current value of of the text field.
         * @return The mask to use in this case via getMask().
         * @see getMask()
         */
        protected String getMaskForString(final String mask, final String value) {
            String maskForString;
            if (getNumberNumericDigits(value) < getMinimumDigits()) {
				maskForString = getMask(mask, getNumberOfOccurances(mask, '*')
                        - getMinimumDigits());
			}
			else {
				maskForString = getMask(mask, getNumberOfOccurances(mask, '*')
                        - (getNumberNumericDigits(value)));
			}
            return maskForString;
        }

        /**
         * Gets the mask for the given number of currently required digits.
         *
         * @param mask
         *            The original mask.
         * @param number
         *            The number of digits we wish to make optional.
         * @return The mask to use.
         */
        private String getMask(final String mask, final int number) {
            StringBuilder maskBuilder = new StringBuilder(mask);
            maskBuilder.trimToSize();
            maskBuilder = maskBuilder.reverse();
            for (int i = 0; i < number; i++) {
                maskBuilder = maskBuilder
                        .replace(maskBuilder.indexOf("*"), maskBuilder
                                .indexOf("*") + 1, String
                                .valueOf(getPlaceholder().charAt(i)) /* "_" */);
            }
            maskBuilder.trimToSize();
            return maskBuilder.reverse().toString();
        }

        /**
         * @param value
         * @return
         */
        private int getNumberNumericDigits(final String value) {
            int count = 0;
            for (int i = 0; i < value.length(); i++) {
                if (Character.isDigit(value.charAt(i))) {
					++count;
				}
            }
            return count;
        }

        /**
         * Counts the number of occurrences of a given character.
         * @param c The character to look for.
         * @return The number of times that character is found.
         */
        private int getNumberOfOccurances(final String string, final char c) {
            int count = 0;
            for (int i = 0; i < string.length(); i++) {
				if (string.charAt(i) == c) {
					++count;
				}
			}
            return count;
        }
    }

    /**
     * Displays popup menu when appropriate.
     *
     * @author Eric Lundin
     */
    private class SpinnerActionListener implements ActionListener {
        Spinner spin;

        /**
         * @param spinner
         *            THe spinner to listen for calendar button events on.
         */
        public SpinnerActionListener(final Spinner spinner) {
            this.spin = spinner;
        }

        /*
         * (non-Javadoc)
         *
         * @see java.awt.event.ActionListener#actionPerformed(java.awt.event.ActionEvent)
         */
        public void actionPerformed(final ActionEvent e) {
            requestFocus();
            calendarPopupMenu = new PopupMenuCalendar((String) spinner
                    .getValue(), spin);
            JComponent comp = (JComponent) e.getSource();
            calendarPopupMenu.show(spinner, comp.getX(), comp.getY());
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void addFocusListener(final FocusListener listener) {
        if (listener != null) {
			eventDispatcher.addFocusListener(listener);
		}
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void removeFocusListener(final FocusListener listener) {
        eventDispatcher.removeFocusListener(listener);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void setEnabled(final boolean arg0) {
        super.setEnabled(arg0);
        spinner.setEnabled(arg0);
        calendarButton.setEnabled(arg0);
    }

    /**
     * Set the spinner as editable or not.
     *
     * @param boo
     */
    public void setEditable(final boolean boo){
        ((JSpinner.DefaultEditor)spinner.getEditor()).getTextField().setEditable(boo);
        calendarButton.setEnabled(boo);
        spinner.setEnabled(boo);
    }

    @Override
    public void requestFocus() {
        ((SpinnerEditor)spinner.getEditor()).getTextField().requestFocus();
    }

}